Skip to content

fix(plugin-email)!: the email template send judges the served holders, so a disabled package holds no name at the send (ADR-0150 D3) - #33361

Draft
objectstack-fleet[bot] wants to merge 5 commits into
mainfrom
claude/area-E2-32670
Draft

objectstack-fleet[bot] wants to merge 5 commits into
mainfrom
claude/area-E2-32670

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread
Part of #32670

Clause-②: yes (narrowing) — widened: a send of a template name an enabled and a disabled package hold, made with no package or from a caller whose closure holds both, is served the enabled holder's template where it was refused naming both. Narrowed: a send that only a disabled holder's side answered (its customization pair's row, a dependent's added locale, the environment's item or a package-less stored row beside a name only it holds) finds no template; a send of an auth name whose one overriding package is disabled is served the built-in copy. Refusals name only the packages that serve the name. With no holder of the name disabled, every answer is unchanged.
Metadata-protocol: none
Skills-docs: none — no skill under skills/** and no page under content/docs/** teaches what an email template send answers when a package holding the name is disabled.

维护者速读

  • 改了什么:邮件模板发送(template-loader.ts 的 bundleAddressOf)在模板名有持有包被停用时,只按"仍在服务的持有包"解析名字,并且只从不含停用包成员的地址读取模板(解析出的持有包自己的地址,或内置模板的地址);停用包自己发送、或没有任何在服务持有包能回答的发送,一律"找不到模板"。没有持有包被停用时,行为与 main 完全相同。
  • 为什么改:objectql + metadata-protocol + runtime: by-name doors judge a name's holders over the served list, so a disabled package neither refuses nor is named (ADR-0150 D3; #32101 F4 class) #32183 让停用包在解析处不再持有名字,但发送门按 R4-A 暂时保持 main 的答案;本卡把发送门也改为判断在服务的持有包,同时不泄露别的包的成员(停用包的定制对行、依赖包追加的语言)。
  • 风险与回滚:风险在"收窄"一侧:停用包一侧原本能发出去的模板(定制对行、依赖包追加语言、与单一停用持有包并存的环境项或无包存储行)现在发不出去;auth 模板的唯一覆盖包被停用时改发内置模板。回滚即还原 template-loader.ts 一个文件。_env 单元格与 R4-G 流程门不在本 PR(见 Acceptance notes)。
  • 先看:packages/plugins/plugin-email/src/template-loader.ts(servedBundleAddressOf,尤其"读哪个地址"的分支);packages/plugins/plugin-email/src/template-loader.test.ts(新 describe 的期望表);.changeset/32670-email-send-served-holders.md(逐行 FROM → TO)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-email exec vitest run src/template-loader.test.ts,预期 76 个测试全部通过。
  • 需要你做的:决定 _env 单元格(需要改 packages/metadata-protocol,属 E1 区域)与 R4-G 流程门的去向;本 PR 落地须等 metadata-protocol: a name held only by stored rows of two packages, one disabled, answers by save order, and /layers takes the first composite when the environment holds its own item (ADR-0150 D3) #32354。

What changes

createRegistryEmailTemplateLoader's bundleAddressOf (packages/plugins/plugin-email/src/template-loader.ts):

  • No holder of the name disabled: the code path is main's, unchanged (resolve, count and the shared view over every holder, which are the served holders here). Measured: 0 moved answers with nothing disabled, across the probe matrix below.
  • A holder disabled (servedBundleAddressOf): the name is resolved over the served holders, so a disabled package is never the answer and a refusal names only the packages that serve the name. The bundle is read only where no member of a disabled holder's side is listed:
    • resolved to a served holder: that holder's own address (as main already read a name two packages hold);
    • resolved to the built-ins (an auth name whose overriding package is disabled): the built-ins' own address, through the closure re-resolve;
    • the caller's own holding is the disabled one, or no served holder answers: no template (NO_BUNDLE, no list is read);
    • the caller holds a dependent's addition only: resolved again from its closure over the served holders, so a dependent of a disabled base never gets a template from outside its closure.
  • Why the read is chosen and never filtered after the read (measured on the real protocol): the environment-wide execution list keeps a disabled holder's customization-pair rows and a dependent's added locales listed, and an item's _packageId there is the slot it fills, not the package that stores it. A pair row filling a locale a dependent also adds wears the dependent's id; the environment's item over a disabled holder's slot wears the disabled holder's id. So the member rule of contract review 6819947096 (a send returns only members stored under the resolved holder, its pair, a dependent whose closure holds it, or no package) is met by choosing the address, never by filtering _packageId.

The card's rows 27 to 31 (PR #32497's round-3 table, withdrawn under R4-A) are restored, and rows 36 to 38 are declared. Numbering: 27 to 31 follow the round-3 changeset's order (rows 29 and 30 are the two refusals naming the enabled holder, per the red-line read 6819956008); 32 to 35 stay #32497's.

Row Address / caller State FROM (main) TO Clause-② arm
27 a send with no package (omitted or null) two holders, one disabled 409 naming both the enabled holder's address (its template; a locale only the disabled side carries: no template) widening
28 a caller whose closure holds both same 409 naming both the enabled holder's address widening
29 a caller whose closure reaches the name only through the disabled holder (a dependent of it included) same no template, or the disabled holder's pair row 409 outside-closure naming the enabled holder refusal
30 a caller that reaches neither (the built-in package as caller included) same 409 outside-closure naming both 409 outside-closure naming the enabled holder names fewer
31 every caller every holder disabled 409 naming them, or the pair's row no template not-found
36 the disabled holder's own send two holders, one disabled its pair's row no template not-found
37 every caller one holder, disabled, beside the environment's item (registered first or last), a package-less stored row, a dependent's addition or the pair's row that member (the environment-wide list), or 409 outside-closure naming it for a caller with no path no template not-found
38 every caller but the package; the dependent an auth name (auth.*) whose one overriding package is disabled the environment-wide list (the disabled package's side, the environment's item, or the built-in), or 409 naming the dependent and the package the built-ins' own address (the built-in template, or a package-less stored row there); the package's own send: no template narrowing (re-targeted)

First-party count 0 on every row: no first-party composition ships an email template name two packages hold, or disables a package that holds one.

Tests

packages/plugins/plugin-email/src/template-loader.test.ts: the R4-A parity describe (a disabled holder still counts at the send), whose premise this card reverses, is replaced by a disabled package holds no name at the send (ADR-0150 D3, #32670):

  • Real world: a SchemaRegistry and the protocol's real getMetaItemsForExecution (ObjectStackProtocolImplementation, aliased to this checkout's source) over stored sys_metadata rows: ALPHA's customization pair ALPHA.custom stores an fr-FR member row; ADDER depends on ALPHA only and adds fr-FR. No double over registry.listItems. The engine double opens findOne with assertEngineFindOnePredicate, and carries no write verb.
  • 11 cases × 2 registration orders × 2 reads (the protocol's, and the registry's own list) = 44 tests; each asserts 9 callers × en-US and fr-FR (792 answers). Callers: omitted, null, ALPHA, BETA, a closure holding both, a closure through ALPHA only, neither, the dependent, the built-in package.
  • Cases: two holders with nothing / ALPHA / BETA / both disabled; two holders with the dependent disabled and ALPHA disabled; one holder disabled beside the environment's item registered first, and last; one holder beside a package-less stored row, nothing disabled and disabled; an auth name one package overrides, nothing disabled and disabled.
  • CONTROLs: all enabled (two holders), one holder (nothing disabled), an auth name (nothing disabled); and the R4-F CONTROL (the built-in package as caller) is kept.
  • A refusal is asserted by its envelope (code RESOURCE_CONFLICT, status 409) and its candidates.

Acceptance notes

Verification

All at 4419624c1a unless named.

Probe matrix (FROM = `main`'s loader, TO = this head's, one process, same registry per answer)

31,104 answers: one or two holders with every subset disabled; beside nothing, the environment's item registered first or last, or a package-less stored row; with and without the pair's stored fr-FR row; no dependent, the dependent enabled or disabled; no built-in, the built-in holding a non-auth name, or an auth name; 9 callers; en-US and fr-FR; both reads; both orders.

  • Moved: 14,681; with nothing disabled: 0.
  • A sent member stored outside the member rule (a disabled holder's own, its pair's row, a disabled or base-disabled dependent's addition, or a holder outside a package caller's closure): main 1,127; this head 107, all 107 unmoved from main and all in nothing-disabled states (the built-in package as caller of a one-holder non-auth name, R4-F's shape); new ones: 0.
Ablations (each through `scripts/ablation-replace.mjs`, restored to the HEAD blob)
  • A1, the served branch removed (bundleAddressOf never calls servedBundleAddressOf): 32 of 76 red, every disabled case in both orders and both reads; the 12 CONTROL tests and the other 32 stay green.
  • A2, not-found reads the environment-wide list instead of no template: 16 of 76 red (rows 31 and 37).
  • A4, the disabled caller's own holding falls through to the re-resolve: 16 of 76 red (two holders with ALPHA or with BETA disabled, the dependent-disabled case, and the auth name; rows 29, 36 and 38).
  • A5, the re-resolve counts every holder: 12 of 76 red (two holders with ALPHA disabled, the dependent-disabled case, and the auth name; rows 29 and 38).
  • A6, the re-resolve's built-ins arm removed: 4 of 76 red (row 38).
  • A3, a duplicate built-ins check before the re-resolve, ablated: 0 of 76 red, and 0 of 31,104 matrix answers moved; it was deleted (26768975c). A2, A4 and A5 ran at b82d77303, whose template-loader.ts differs from the head's only by that deleted line; A1 ran again at the head.
Tests and gates
  • pnpm --filter @objectstack/plugin-email test: Test Files 53 passed (53), Tests 1010 passed (1010) (lock VERDICT command-exit 0).
  • pnpm --filter @objectstack/plugin-email typecheck (tsc, then check:test-typecheck): exit 0, check:test-typecheck: OK.
  • eslint --no-inline-config on the two touched source files: exit 0.
  • The derived local gate families (dispatch-gates.mjs --commands, 79 commands): 75 exit 0 on the first run; check-adr-0087-registration, check:engine-double-contract and check:test-source-alias were red on the first run and are exit 0 after 4419624c1a (the ADR-0087 disposition, the pinned findOne and its ledger line, the protocol's source alias). check:dual-build-cjs-loads: NOT MEASURED, reason: PREREQUISITE NOT MET (it reads every package's dist/; CI runs it).
  • Dependents of @objectstack/plugin-email (60 downstream packages, through rest and client): not run locally; the public surface is byte-unchanged (no export, type or signature moves; the change is module-internal), and Test Core runs the affected set. The full dogfood battery is owed by the narrowing arm and is reported with the dev report.

Generated by Claude Code

When a holder of a template name is disabled, the send resolves the name
over the served holders, so a disabled package is never the answer and a
refusal names only the packages that serve the name. The answer is only
ever an address whose list holds no member of a disabled holder's side:
the resolved served holder's own address, the environment-wide list for
the environment's own item, or no template when the caller's own holding
is the disabled one or no served holder answers it. With no holder
disabled, every read is the one it was.

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
…r a real stored pair row

Replaces the parity pin (a disabled holder still counted at the send) with
the declared answers: two holders with each set disabled, a dependent's
addition enabled and disabled, one holder beside the environment's item
(registered first or last) or a package-less stored row, and an auth name
one package overrides, every caller class, en-US and fr-FR, both reads and
both registration orders, over the protocol's execution list with a real
stored customization-pair row. CONTROLs: nothing disabled; one holder.

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
… the closure re-resolve

The first check repeated what the re-resolve answers: a send resolved to
the built-ins is resolved again over the served holders and read at the
built-ins' own address. Ablating it moved no answer of the pins or of the
probe matrix.

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
…ocol to source, and answer the ADR-0087 question

The engine double the send pins read stored rows through opens findOne with
assertEngineFindOnePredicate, and the pinned ledger records it. The protocol
the pins read through resolves to this checkout's source. The changeset says
why no ADR-0087 entry is owed: every moved answer follows a package's
enabled state.

Claude-Session: https://claude.ai/code/session_016WV4cyrFFk86Jqf7Mp5Rzw
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-email/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-email/vitest.config.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d3f3f4abf9f392a49fecf77f2175424fe1680bef → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b15c702f304777010f7f6ac8734277a513317c63 — the merge of head 4419624c1a5eab1741ba6f47502180d001bd6b93 into base d3f3f4abf9f392a49fecf77f2175424fe1680bef, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b15c702f304777010f7f6ac8734277a513317c63 && git checkout b15c702f304777010f7f6ac8734277a513317c63
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d3f3f4abf9f392a49fecf77f2175424fe1680bef 4419624c1a5eab1741ba6f47502180d001bd6b93 && git checkout -B drift-repro d3f3f4abf9f392a49fecf77f2175424fe1680bef && git merge --no-ff 4419624c1a5eab1741ba6f47502180d001bd6b93

node scripts/docs-audit/affected-docs.mjs --json d3f3f4abf9f392a49fecf77f2175424fe1680bef

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4cyrFFk86Jqf7Mp5Rzw
Request: review · area E2 · head 4419624

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4d1PGptBW3QRQNmy5e6 · head 4419624 · 2026-11-14T18:12:36Z

review#3.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4419624c1a5eab1741ba6f47502180d001bd6b93
Local-runs: none
Scope: packages/plugins/plugin-email/src/template-loader.ts, .changeset/32670-email-send-served-holders.md + packages/plugins/plugin-email/src/template-loader.test.ts, packages/plugins/plugin-email/vitest.config.ts, packages/plugins/plugin-email/package.json, scripts/engine-double-contract.pinned.json, docs/adr/0150 (D3, D4)

① Derived judgments

  • The nothing-disabled path is main's. bundleAddressOf computes holders (all, built-ins filtered) and served (without includeDisabledPackages), and branches only when served.length < holders.length. Otherwise the code is main's: the old inline shared registry is now withoutCallerHolding(scoped, { includeDisabledPackages: true }), the same object. The probe reports 0 of 31,104 answers moved with nothing disabled. That satisfies the red line "one-holder behaviour unchanged" for every enabled composition.
  • servedBundleAddressOf. It resolves over the served holders only, so a refusal names only serving packages (rows 29–30) and a disabled package is never the answer:
    • resolved to a served package: that package's own address, the same list main already used for a two-holder name;
    • resolved to a package that is not served: only reachable when the caller's own holding is the disabled one, and it answers NO_BUNDLE (row 36);
    • not resolved: NO_BUNDLE (rows 31, 37);
    • an addition-only caller, or the built-ins: re-resolved from the caller's closure over the served holders, and read only at the built-ins' address or a served holder's address (rows 29, 38).
      Every answer that can carry a member is a package-own or built-ins address. The environment-wide list is never read while a disabled holder exists, except on the environment-item branch.
  • Choosing the address rather than filtering after the read is justified: the execution list's _packageId is the slot an item fills, not where it is stored. A post-read filter on it would either leak a pair row or drop a dependent's locale. This is the only way to meet the member rule of 6819947096 until metadata-protocol: a name held only by stored rows of two packages, one disabled, answers by save order, and /layers takes the first composite when the environment holds its own item (ADR-0150 D3) #32354 lands. Measured: out-of-rule sends drop from 1,127 to 107, none new, and all 107 are the unmoved R4-F nothing-disabled shape.
  • nit N1: the environment-item branch (resolved.holder.kind !== 'package' → undefined, the environment-wide read) is unreachable in the matrix, because the registry answers no environment item for a name any package holds. If it ever became reachable, it is the one branch that would read the environment-wide list beside a disabled holder. NO_BUNDLE there would make "no disabled side member is ever read" hold by construction instead of by reachability. This is optional, since the author keeps main's answer deliberately.
  • Pins. 11 cases × 2 orders × 2 reads (protocol real list and registry list) × 9 callers × 2 locales. Built on a real SchemaRegistry and the real getMetaItemsForExecution over stored rows, with no double over listItems; the findOne double is pinned in the engine-double ledger (+1 row). Each ablation (A1, A2, A4, A5, A6) turns its rows red, and A3 was measured as dead code and deleted.

② Semver level

@objectstack/plugin-email: patch, fix(plugin-email)!, Clause-②: yes (narrowing) with the widening rows in prose, a BREAKING paragraph, and one adr-0087: not-required (no-migration-prescription) marker. Correct for a non-protocol narrowing (AGENTS.md). Metadata-protocol: none and Skills-docs: none — reason hold.

③ Boundary flags

④ Design judgment

Row 37, as asked: accepted as declared (no template). Under ADR-0150 D4, an environment item or package-less row for a name a package holds is that package's customization. The registry already answers it that way (getEnvironmentScopedItem is undefined for a held name, and the execution list stamps the item with the holder's slot). A dependent's addition and the pair row also belong to the holder's side. When the only holder is disabled, its whole side, customization included, holds no name at the send (D3 as ruled on #32183). Serving the environment's item there would treat a customization as an independent environment item, which would loosen resolution and depend on the disable state in a way D4 does not provide. Withholding it is reversible: re-enabling the package restores the same answer. This is a judgment on a declared row, not a new seat ruling. If the seat wants the opposite answer, that would be a behaviour-changing ADR interpretation and needs its Fable pass.

The rest holds: judging the send over the served holders completes D3 at the last door #32183 left at main (R4-A). Choosing the address over filtering is forced by what the execution list carries, and it keeps the member rule.

Implemented-by: claude/area-E2-32670
Reviewed-by: session_016WV4d1PGptBW3QRQNmy5e6

VERDICT: PASS

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants